feat(trust): scoped tokens — kb:read / kb:propose / kb:approve / kb:admin - #692
feat(trust): scoped tokens — kb:read / kb:propose / kb:approve / kb:admin#692minion1227 wants to merge 5 commits into
Conversation
…dmin a bearer token was all-or-nothing: hold it and you could call all 73 kb.* methods, kb.approve included. fine for a solo human, wrong for a ci job that should only read or a triage bot that should only propose. the config-level trusted-agent flag can only widen the gate; this is the first thing in vouch that can narrow it — withholding kb:approve by default *is* the review gate, expressed as a credential. four coarse scopes over the method list rather than a per-method allowlist, because a per-method grammar makes every new kb.* method a config migration for every deployment. two rules keep this safe to ship into existing deployments. an unscoped credential means all scopes, so every token issued before this keeps working exactly as it did — an empty scope set is "unrestricted", never "denied". and every method must be classified: METHOD_SCOPES is exhaustive over capabilities.METHODS with a test enforcing it, so a newly-added method cannot silently land unreachable for scoped callers. an unclassified method is denied to a scoped caller — fails closed, because a deny-list in a trust-centric system fails open. enforcement lives at the two dispatch points that already exist: handle_request for jsonl and http, and wrap_tool_fn for mcp, both routed through trust.require_scope so the three surfaces inherit one implementation. the check runs before the handler, so a refused call cannot have side effects on its way to being refused. the agent registry supplies the scopes: a registered subject's scopes ride onto VouchTrust at the http chokepoint, and an unregistered subject stays unscoped. registration now validates scopes, so a typo cannot mint a credential with powers nobody asked for. kb.capabilities reports the caller's effective scopes and allowed methods, so an agent discovers what it may do instead of failing method by method. the trust block only grows a scopes key when the credential is actually scoped. stacked on vouchdev#607: the registry is where scopes are stored. Closes vouchdev#608
the schema drift check compares schemas/ against what scripts/gen_schemas.py emits from models.py. adding the per-credential scopes block to Capabilities changed that output, so the committed schema went stale. this is the sixth registration site a new field can miss — CLAUDE.md's four, plus hot_memory's coverage map, plus this. worth a line in the contributor notes separately.
|
the two safety rules are the right two, and the direction on both is correct. an unscoped credential meaning all scopes is the only choice that does not break every deployment on upgrade, and an unclassified method being denied to a scoped caller is the only choice that fails closed — getting either backwards would be the bug that matters here, and
the two small distinctions are also right and easy to get wrong: an unknown method still reporting housekeeping — #669 merged this morning (05:47Z), so the stacking note at the top of the description is now stale and the diff needs a rebase. on the VEP question you raise yourself: i think you are right that the additive shape avoids needing one — |
|
closing this one reluctantly — the design is right and i want it back, but
by this PR's own rule — "an unclassified method is denied to a scoped caller — fails closed" — every scoped credential would lose access to goal proposals and correction capture the moment this lands. that is the correct failure direction, but it is still a regression, and it is invisible from the diff. your own i also merged this into a local integration alongside the other eleven open PRs and ran the full suite — the eleven pass together; adding this one is what breaks it. so the objection is specific to the staleness, not to any interaction with the rest. everything else i said stands. an unscoped credential meaning all scopes is the only choice that does not break deployments on upgrade; an unclassified method failing closed is the only safe direction for a deny-list; to bring it back: rebase onto |
Closes #608
a bearer token was all-or-nothing: hold it and you could call all 73
kb.*methods,kb.approveincluded. fine for a solo human, wrong for a CI job that should only read or a triage bot that should only propose. the config-leveltrusted-agentflag can only widen the gate — this is the first thing in vouch that can narrow it.withholding
kb:approveby default is the review gate, expressed as a credential.four coarse scopes, not a per-method allowlist
kb:read/kb:propose/kb:approve/kb:admin. a per-method grammar is more flexible and makes every newkb.*method a config migration for every deployment; four buckets keep that cost at zero.the two rules that make this safe to ship
an unscoped credential means all scopes. every token issued before this keeps working exactly as it did. an empty scope set is unrestricted, never denied — the opposite choice breaks every deployment on upgrade.
test_unscoped_permits_everythingwalks all 73 methods.every method must be classified.
METHOD_SCOPESis exhaustive overcapabilities.METHODS, enforced both ways:test_every_method_is_classifiedcatches a new method with no scope, andtest_the_table_has_no_entries_for_methods_that_do_not_existcatches a stale entry. an unclassified method is denied to a scoped caller — fails closed, because a deny-list in a trust-centric system fails open.one implementation, three surfaces
enforcement is
trust.require_scope, called from the two dispatch points that already exist:handle_requestfor JSONL/HTTP andwrap_tool_fnfor MCP. the check runs before the handler, so a refused call cannot have side effects on its way to being refused —test_denial_happens_before_the_handler_runsspies on the handler and asserts it never ran.a couple of details worth checking:
method_not_found, notpermission_denied— scoping must not turn a typo into a permissions puzzle._meta.vouch_trustonly grows ascopeskey when the credential is actually scoped, so unscoped callers see the byte-identical block they saw before.discovery, not trial and error
kb.capabilitiesreports the caller's effective scopes and the exact method list they may call, so an agent finds out what it may do up front instead of failing method by method.note on the VEP question
the issue says this "probably wants a VEP" as a surface change. i think the additive shape avoids needing one —
kb.capabilitiesgains a field,_meta.vouch_trustgains an optional key, and unscoped behaviour is unchanged — but that is your call, and i would rather raise it than assume. happy to write one if you want the surface change recorded.scope enforcement is here; #609's per-KB credential binding is not, and
scopesstays plain recorded metadata on the registry row for anything else to build on.tests
46 cases in
tests/test_scopes.py: the exhaustiveness guards, representative methods per bucket,permitsincluding the unscoped and unclassified paths,parse_scopesnormalisation and its refusal of a typo'd scope, enforcement across all three credential shapes on the JSONL dispatch, the sync and async MCP wrappers, the trust block,kb.capabilities, and the MCP-over-HTTP ASGI middleware resolving registry scopes onto the request.verification